<?php

namespace mongrove;

/**
 *
 * The Query into a Mongo collection. Mongrove queries always
 * over a given type, enabling it to return one or a collection
 * of Records of the same type, or a subtype of the given type.
 *
 * @author Michiel Hakvoort <michiel@hakvoort.it>
 * @author Gido Hakvoort <gido@hakvoort.it>
 *
 */
class Query {

    /**
     * Sort in ascending order
     *
     * @var integer
     */
    const SORT_ASC 	= 1;

    /**
     * Sort in descending order
     *
     * @var integer
     */
    const SORT_DESC = -1;

    protected $type;
    protected $blueprint;

    protected $queryService = null;

    protected $select = array();
    protected $limit = null;
    protected $skip = null;
    protected $orderBy = array();
    protected $query = array();

    /**
     *
     * Instantiate a new Query for the given record and with the given internal structure.
     *
     * @param string $type The type of the Record queried for
     * @param \mongrove\Structure $blueprint The internal structure of the Record
     * @param \mongrove\CollectionQueryService $queryService The service responsible for executing queries
     */
    public function __construct($type = null, $blueprint = null, CollectionQueryService $queryService) {
        $this->type = $type;
        $this->blueprint = $blueprint;
        $this->queryService = $queryService;
    }

    /**
     *
     * __call method for simple sorting calls
     *
     * @param $name
     * @param $arguments
     *
     * @return Query
     */
    public function __call($name, $arguments) {
        if(substr($name, 0, 6) === 'sortBy') {
            $method = 'sortBy';
            $field = strtolower(substr($name, 6, strlen($name)));
        } else {
            throw new \Exception("Unknown method: '{$name}' ");
        }

        if(!isset($arguments[0])) {
            throw new \Exception("You must specify a value for '{$name}'.");
        }

        if(!$this->blueprint->hasField($field)) {
            throw new \Exception("Field: '{$field}' does not exist for '{$this->type}'.");
        }

        return $this->$method($field, $arguments[0]);
    }

    /**
     * Only select and set the given fields into the Record(s) queried for. The selection
     * is a string denoting a set of fields or subfields of the record. Subfields can be
     * targeted by a separating dot. For example "name, age" can select a name and age,
     * "name, home.place, home.street" can select the fields name and subfields "place"
     * and "street" of the field "home".
     *
     * @param $expression The selection expression
     *
     * @return Query
     */
    public function select($expression) {
        $fields = explode(",", $expression);

        foreach($fields as $field) {
            $field = trim($field);

            if(!$this->blueprint->hasField($field)) {
                throw new \Exception("Field: '{$field}' does not exist for '{$this->type}'.");
            }

            $this->select[$field] = true;
        }

        return $this;
    }

    /**
     * Add a constraint to which the resulting Records should adhere. The constraint
     * is expressed in the form of an expression and the value to which the expression
     * should adhere. For example ("name is ?", "John") requires the Record's field "name"
     * to equal "John", or ("home.place like ?", "/^new/i") would match all Record fields
     * where "home.place" would start with "new". All possible operations are documented
     * in the extended documentation.
     *
     * @param $expression The expression which places the constraint
     * @param $value The value which is placed in the expression
     *
     * @return Query
     */
    public function where($expression, $value = null) {
        $identifierRegex = <<<REGEX
        {^                              # start of string
        (?J)                            # allow overwriting capture names
        \s*                             # ignore leading whitespace
        (?P<identifier>
            (?:[a-z_][a-z0-9_]*)        # start with a simple string (e.g. foo)
            (?:\.[a-z_][a-z0-9_]*)*     # allow additional subpaths (e.g. foo.bar.baz)
        )

        \s+                             # require whitespace between the identifier and the operator

        (?:
            (?:
                (?P<operator>           # all operators requiring a parameter
                    >|>=|<|<=|=|!=|lt
                    |gt|lte|gte
                    |less\s+than
                    |greater\s+than
                    |eq|is|neq|ne
                    |in|not\s+in
                    |all\s+in|counts|like
                )
                \s*\?                   # followed by the questionmark (the parameter)
            )
            |
            (?P<operator>               # all non-parametric operators
                is\s+set|is\s+not\s+set
            )
        )
        \s*                             # ignore trailing whitespace
        $}mix
REGEX;

        if(!preg_match($identifierRegex, $expression, $matches)) {
            throw new \Exception("Invalid condition in '{$expression}'");
        }

        $identifier = $matches['identifier'];
        $operator = $matches['operator'];

        $operator = strtolower(preg_replace('#\s+#mi', '_', trim($operator)));

        switch($operator) {
            case '<':
            case 'lt':
            case 'less_than':
                $mongoOperator = Command :: CON_OP_LESS;
                break;
            case '>':
            case 'gt':
            case 'greater_than':
                $mongoOperator = Command :: CON_OP_GREATER;
                break;
            case 'like':
                $mongoOperator = Command :: CON_OP_LIKE;
                break;
            case '<=':
            case 'lte':
                $mongoOperator = Command :: CON_OP_LESS_OR_EQUAL;
                break;
            case '>=':
            case 'gte':
                $mongoOperator = Command :: CON_OP_GREATER_OR_EQUAL;
                break;
            case '!=':
            case 'ne':
            case 'neq':
                $mongoOperator = Command :: CON_OP_NOT_EQUAL;
                break;
            case '=':
            case 'is':
            case 'eq':
                $mongoOperator = Command :: CON_OP_EQUAL;
                break;
            case 'in':
                $mongoOperator = Command :: CON_OP_IN;
                break;
            case 'not_in':
                $mongoOperator = Command :: CON_OP_NOT_IN;
                break;
            case 'all_in':
                $mongoOperator = Command :: CON_OP_ALL_IN;
                break;
            case 'counts':
                $mongoOperator = Command :: CON_OP_SIZE;
                break;
            case 'is_set':
                $mongoOperator = Command :: CON_OP_EXISTS;
                $value = true;
                break;
            case 'is_not_set':
                $mongoOperator = Command :: CON_OP_EXISTS;
                $value = false;
                break;
        }

        return $this->whereOperator($identifier, $mongoOperator, $value);
    }

    /**
     *
     * The whereOperator function is equivalent to the where function,
     * but is used for creation of queries in mechanical queries.
     *
     * @param $identifier
     * @param $operator
     * @param $value
     *
     * @return $this
     */
    public function whereOperator($identifier, $operator, $value) {
        $identifier = mb_strtolower($identifier);

        if($identifier === Constant::ID && !($value instanceof \MongoId)) {
            $value = new \MongoId($value);
        }

        if($identifier !== Constant :: ID && !$this->blueprint->hasField($identifier)) {
            throw new \Exception("Field: '{$identifier}' does not exist for '{$this->type}'.");
        }

        $operator = strtolower(preg_replace('#\s+#mi', '_', trim($operator)));

        $where = array();

        switch($operator) {
            case Command :: CON_OP_IN:
            case Command :: CON_OP_NOT_IN:
            case Command :: CON_OP_ALL_IN:
                if(!is_array($value)) {
                    throw new \Exception("You must specify an array for the 'in' operator");
                }
                break;

        }
        $mongoOperator = $operator;

        if(!isset($this->query)) {
            $this->query = array();
        }

        $query =& $this->query;

        if(isset($query[$identifier][$mongoOperator])) {
            throw new \Exception("Operator '{$operator}' is already specified.");
        }

        if((isset($query[$identifier]) && !isset($mongoOperator))) {
            throw new \Exception("Operator '{$operator}' can not be set due to a previous set operator.");
        }

        if(!isset($value)) {
            throw new \Exception("You must specify a value for '{$expression}'");
        }

        $argument = array($mongoOperator => $value);

        if(isset($query[$identifier])) {
            $query[$identifier] = array_merge($query[$identifier], $argument);
        } else {
            $query[$identifier] = $argument;
        }

        return $this;
    }

    /**
     *
     * Limit the size of the result of the query execution.
     *
     * @param integer $limit The limit to be set on the amount of results the query can return
     *
     * @return \mongrove\Query
     */
    public function limit($limit) {
        $this->limit = $limit;

        return $this;
    }

    /**
     * Sort the results (additionally) by the given field. If any previous sorts are
     * set, the additional sort fields will be appended to previously set sort fields.
     *
     * @param $field The field to sort by
     * @param integer $value Either SORT_ASC or SORT_DESC, respectively for an ascending or a descending sort
     *
     * @return \mongrove\Query
     */
    public function sortBy($field, $value) {
        if(!isset($this->orderBy)) {
            $this->orderBy = array();
        }

        $this->orderBy[$field] = $value;

        return $this;
    }

    /**
     *
     * Skip the first given amount of Records of the Query result.
     *
     * @param $offset The amount of Records to skip
     *
     * @return \mongrove\Query
     */
    public function skip($offset) {
        $this->skip = $offset;

        return $this;
    }

    /**
     *
     * Execute the Query with all set properties
     *
     * @return \mongrove\QueryResult
     */
    public function execute() {
        if(count($this->select) > 0) {
            $this->select[Constant :: TYPES] = true;
        }

        /*
         * Create the find cursor according to set limits and skips
         */
        //        if($this->limit === 1 && ($this->skip === 0 || $this->skip === null) && empty($this->orderBy)) {
        //            $cursor = $this->queryService->findOne(..., ....);
        //        } else {
        $cursor = $this->queryService->find();
        //        }

        if(!isset($cursor)) {
            return new QueryResult(null);
        }

        $cursor->fields($this->select);

        if(isset($this->orderBy)) {
            $cursor->addOption(Constant :: OPTION_ORDERBY, $this->orderBy);
        }

        if(isset($this->query)) {
            $query =& $this->query;

            foreach($query as $identifier => $partialQuery) {
                if($identifier !== Constant :: ID) {

                    $query[$identifier] = $this->blueprint->getField($identifier)->rewriteQuery($partialQuery);

                    if(count($query[$identifier]) > 1 && isset($query[$identifier][Command :: CON_OP_EQUAL])) {
                        throw new \Exception("Equality operator cannot be used in combination with non-equality operator for identifier '{$identifier}'");
                    }

                }

                /*
                 * Reduce $eq operator to its canonical query form
                 */
                if(isset($query[$identifier][Command :: CON_OP_EQUAL])) {
                    $query[$identifier] = $query[$identifier][Command :: CON_OP_EQUAL];
                }
            }
        }

        $this->query[Constant :: TYPES] = mb_strtolower($this->type);

        $cursor->addOption(Constant :: OPTION_QUERY, $this->query);

        if(isset($this->skip)) {
            $cursor = $cursor->skip($this->skip);
        }

        if(isset($this->limit)) {
            $cursor = $cursor->limit($this->limit);
        }

        return new QueryResult($cursor);
    }
}